Repository navigation
Plugin loader: version gate reads running code, SystemExit isolated, uv quarantine from any cwd, impostor dirs refused, range pins (#72052 #104404 #101962 #112096 #108371 #71650 #86992 #98407) - #118841
Merged
Conversation
૮ >ﻌ< ა ci reviewran on 0d49490 — test(plugins): langfuse manifest test reads provides_hooks ( debug infoCI timingsCI timings · View report · View jobWall time 4m57s vs 5m44s (-13.7%). 3 job(s) slower, 8 faster, 2 unchanged.
|
…it, quarantine deps everywhere - requires_hermes compared against stale editable-install dist metadata (0.21.0) while the checkout ran 0.21.4, skipping plugins that required the release in use; hermes_cli.__version__ is now the source of truth, dist metadata only a fallback. - PEP 440 pre/post suffixes glued to a segment (99.0.0rc1) made a clause permissive and an rc running version disabled every gate; the segment parser drops the suffix. - A plugin calling sys.exit() at import or in register() propagated SystemExit out of discovery: the whole registry emptied and `hermes chat` exited 3 with no output. Load isolation now covers SystemExit (KeyboardInterrupt still propagates). - uv reads [tool.uv] exclude-newer from the cwd project only, so lazy/plugin dep installs launched from $HOME, a gateway service or the Desktop backend were never quarantined; the uv tier now runs from the checkout root when one exists. - A flat user/project manifest naming a bundled key from a differently named directory no longer displaces the bundled plugin (warn + skip); a same-named override is logged at INFO.
Plugin register() helpers commonly pass the SKILL.md location as a filesystem string (PluginManifest.path itself is stored as str), but register_skill() called path.exists() directly, so a valid string path aborted the whole plugin load with "'str' object has no attribute 'exists'" instead of registering. Coerce to Path up front so the registry entry and find_plugin_skill() keep their Path contract, and a missing location still fails with FileNotFoundError. Fixes #104404
EntryPoint.load() resolves the module:function form to the referenced attribute (typically the register callable itself), not its module. The loader then looked for a .register attribute on that function object, found none, and warned "Plugin '<name>' has no register() function" on every discovery pass — so pip plugins with module:function entry points never registered at all. Detect a non-module callable from ep.load() and use it directly as the register function, resolving LoadedPlugin.module from sys.modules via the callable's __module__ for attribution. The existing entry-point test masked this because its mocked ep.load() returned the module even though its declared value was module:function; the new regression test mirrors real importlib behavior. Fixes #72052 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Walking __pycache__ and other dunder children let an unreadable directory raise out of plugin discovery and fail unrelated tool calls. Skip those names and treat iterdir/is_dir OSError as an empty scan for that node. Fixes #86996
Name-based dunder skipping does not need an unreadable __pycache__. Drop the chmod(0) setup so the test is portable, and log skipped dunder paths at debug.
Multi-harness plugin repos (e.g. obra/superpowers) ship one plugin.json per OTHER agent harness inside .claude-plugin/, .codex-plugin/, .cursor-plugin/, .devin-plugin/ and .kimi-plugin/. Those manifests can never satisfy the Agent Plugins v1 schema, so every discovery pass rejected each one and logged a warning — ~1,500 warnings/day on the reporter's install (#101962). Skip these well-known per-harness convention directories during directory scanning. A plugin's real Hermes manifest (.hermes-plugin/plugin.yaml or a top-level plugin.yaml/plugin.json) is unaffected, and genuinely broken portable manifests still warn. Fixes #101962
…mes list-typed files and reads hooks: - hindsight-client==0.6.1 / mem0ai==2.0.10 exact pins made _is_satisfied() reject every newer compatible release, so hermes update kept downgrading a working client and broke embedded daemons whose DB a newer client had migrated (#86992, #39424, #98407, #99317). The lazy entries now mirror the plugin manifests (>=0.6.1,<1 and >=2.0.10,<3); pyproject extras stay the floor install. Slim redo of #99557 (nateEc) / #98416 / #98527 (ttomiczek) / #39754. - A list-typed plugin.yaml is refused with "top level must be a mapping" instead of an AttributeError swallowed as "Failed to parse" (#14066, discovery side). - ``hooks:`` (the spelling bundled manifests carried) still populates provides_hooks (#108371).
Plugins register their toolsets during background discovery, but the CLI validates the configured toolset list while the instance is being constructed -- i.e. before that thread has landed. Judging by the live registry alone therefore reports every configured plugin toolset as a typo on every launch. The warning is not merely cosmetic: it is written to the console, so one-shot and quiet runs (-q / -Q, --format stream-json) hand it to whatever parses their output; an integration reading the response can receive the warning line instead of the answer. Skip names the plugin registry knows about, mirroring how MCP server names are already skipped here. Names persisted by the previous launch's discovery sweep are served by get_plugin_toolset_keys_nowait, so this stays non-blocking on startup. A genuinely unknown name still warns. Refs #71650 (#95529 is the duplicate report of the same bug) Co-authored-by: adamkrawczyk <adam-krawczyk@outlook.com>
teknium1
force-pushed
the
fix/plugin-loader-robustness
branch
from
September 22, 2026 06:20
fa2ef1a to
0d49490
Compare
This was referenced Sep 22, 2026
This was referenced Sep 22, 2026
teknium1
added a commit
that referenced
this pull request
Sep 23, 2026
…licy Hermes's 14-day `[tool.uv] exclude-newer` quarantine applies to Hermes's own dependencies only (uv lock/sync, `hermes update`, LAZY_DEPS extras via `ensure()`). A plugin's declared `python_dependencies` install under the PLUGIN's policy: `install_specs(policy="plugin")` runs uv with `--no-config` from any cwd, still inside the core constraints file. Reverses item 3 of #118841, which ran the uv tier with cwd=<checkout> for every install so the quarantine reached plugin deps from any cwd. That made catalog re-pins floored on a <14-day release uninstallable (#120076: "only hindsight-client<=0.9.2 is available"; #114530 held on the same gate). Maintainer ruling (Teknium): "plugins dont have to abide by our 14 day rule btw. They can have their own security policy on that. Only hermes' dependencies themselves have to. We should recommend that they do this for their plugins and we should give guidance to plugin devs that they should though." - tools/lazy_deps.py: INSTALL_POLICIES ("core" | "plugin"); `_uv_policy_args` replaces `_uv_policy_cwd`; `_venv_pip_install(policy=)` defaults to core (ensure/LAZY_DEPS), `install_specs(policy=)` defaults to plugin. - hermes_cli/plugin_python_deps.py: `resolve()` passes policy="plugin". - Docs: developer guide "Dependency security policy" section, catalog README admission rule 9, AGENTS.md pinning policy — plugin authors are responsible for their deps and strongly recommended to pin upper bounds, floor on the oldest API-compatible version and run their own release quarantine (`uv --exclude-newer` in their CI); operators can set UV_EXCLUDE_NEWER. - Tests: the #118841 cwd test is replaced by two invariants — a plugin install carries `--no-config` and no checkout cwd (red on base), a core lazy install keeps the checkout cwd and no `--no-config`.
teknium1
added a commit
that referenced
this pull request
Sep 23, 2026
…licy Hermes's 14-day `[tool.uv] exclude-newer` quarantine applies to Hermes's own dependencies only (uv lock/sync, `hermes update`, LAZY_DEPS extras via `ensure()`). A plugin's declared `python_dependencies` install under the PLUGIN's policy: `install_specs(policy="plugin")` runs uv with `--no-config` from any cwd, still inside the core constraints file. Reverses item 3 of #118841, which ran the uv tier with cwd=<checkout> for every install so the quarantine reached plugin deps from any cwd. That made catalog re-pins floored on a <14-day release uninstallable (#120076: "only hindsight-client<=0.9.2 is available"; #114530 held on the same gate). Maintainer ruling (Teknium): "plugins dont have to abide by our 14 day rule btw. They can have their own security policy on that. Only hermes' dependencies themselves have to. We should recommend that they do this for their plugins and we should give guidance to plugin devs that they should though." - tools/lazy_deps.py: INSTALL_POLICIES ("core" | "plugin"); `_uv_policy_args` replaces `_uv_policy_cwd`; `_venv_pip_install(policy=)` defaults to core (ensure/LAZY_DEPS), `install_specs(policy=)` defaults to plugin. - hermes_cli/plugin_python_deps.py: `resolve()` passes policy="plugin". - Docs: developer guide "Dependency security policy" section, catalog README admission rule 9, AGENTS.md pinning policy — plugin authors are responsible for their deps and strongly recommended to pin upper bounds, floor on the oldest API-compatible version and run their own release quarantine (`uv --exclude-newer` in their CI); operators can set UV_EXCLUDE_NEWER. - Tests: the #118841 cwd test is replaced by two invariants — a plugin install carries `--no-config` and no checkout cwd (red on base), a core lazy install keeps the checkout cwd and no `--no-config`.
This was referenced Oct 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Plugin loading now gates on the version that is actually running, survives a plugin that calls
sys.exit(), installs plugin deps under the uvexclude-newerquarantine from any cwd, refuses impostor directories that claim a bundled plugin's key, and stops downgrading newer hindsight/mem0 SDKs — plus seven salvaged contributor fixes for loader edge cases.Fix pack from the 2026-09-21 plugin-loader audit (lane L1) and bug-sweep clusters 4/6/8. Every symptom was live-reproduced on
origin/mainin a tempHOME/HERMES_HOMEbefore the fix and re-run after.Changes
Loader (own fixes)
requires_hermescompared against stale editable-install dist metadata (importlib.metadatasaid 0.21.0 while the checkout ran 0.21.4), so a plugin requiring>=0.21.4was skipped on 0.21.4.running_hermes_version()now readshermes_cli.__version__; dist metadata is the fallback.99.0.0rc1) parsed asNone: an rc target silently gated nothing and an rc running version disabled every gate._version_tupledrops the suffix.sys.exit()at import or inregister()propagatedSystemExitout of discovery: the whole registry emptied,_discoveredreset andhermes chat -Qexited with no output._load_plugin_scoped/_load_portable_plugin/ deferred-platform tool pre-registration now isolateSystemExit(named asSystemExit(code) raised during import/register());KeyboardInterruptstill propagates.[tool.uv] exclude-newerfrom the cwd project only, soinstall_specs/ lazy installs launched from$HOME, a gateway service or the Desktop backend never got the 14-day quarantine (verified withuv pip install --show-settings:exclude_newer: Nonefrom$HOME,14dfrom the checkout). The uv tier now runs withcwd=<checkout root>whenpyproject.tomlexists, which also brings the[tool.uv.exclude-newer-package]exceptions along. Settles the "does quarantine cover plugin deps?" question: it does now, everywhere.name:is a bundled key but whose directory is named differently (~/.hermes/plugins/impostor_dirwithname: disk-cleanup) displaced the bundled plugin silently, sohermes plugins enable disk-cleanupactivated unrelated code.resolve_manifest_winners(plugins_discovery) keeps the bundled manifest and warns; a same-named user copy still overrides (documented later-wins) and is logged at INFO.LAZY_DEPSexact pins (hindsight-client==0.6.1,mem0ai==2.0.10) made_is_satisfied()reject every newer compatible release, sohermes update(and hindsight's own>=_MIN_CLIENT_VERSIONauto-upgrade) kept downgrading a working 0.9.x client and broke embedded daemons whose DB a newer client had migrated. Policy: plugin-owned SDK entries mirror the range theirplugin.yamldeclares (>=0.6.1,<1,>=2.0.10,<3); pyproject extras stay the floor install (tests/test_project_metadata.pyonly cross-checks packages exact-pinned on both sides). Slim redo of fix(mem0): preserve compatible installed releases #99557 (@nateEc), fix(deps): bump mem0ai exact pins from 2.0.10 to 2.0.19 #98416, fix(memory): align Hindsight client pin at 0.9.2 #98527 (@ttomiczek), fix(plugins): repair hindsight local runtime activation (#39424) #39754.plugin.yamlis refused withtop level must be a mappinginstead of anAttributeErrorswallowed as "Failed to parse" (plugins install crashes when plugin.yaml is valid YAML but not a mapping #14066, discovery side; theplugins_cmd_read_manifestpath is a separate file and is left for its lane).hooks:(the spelling the bundled manifests carried) still populatesprovides_hooks, so external copies keep declaring the same thing.Salvaged contributor fixes (cherry-picked, authorship preserved, re-sited onto the post-#102117 siblings)
module:functionregister via the callable instead of "no register() function" every pass (Entry-point plugins declared as module:function never register — loader calls getattr(register, 'register') and warns every discovery pass #72052).ctx.register_skill(name, "<str path>")coerces toPath(Plugin load fails with "'str' object has no attribute 'exists'" #104404)..<harness>-plugin/manifest dirs skipped instead of one WARNING per pass (Plugin discovery: warning spam for foreign-harness plugin.json manifests (obra/superpowers layout) #101962).__pycache__) skipped;iterdir()OSError contained (Plugin scanner: dunder dirs are not skipped, and a scan error fails every tool call #86996 residual).plugins/plugin_loader.pypops a failed sibling module fromsys.modulesso the nextfrom .sib import XraisesModuleNotFoundError, not a misleadingImportError(plugins/plugin_loader.py: failed sibling exec leaks a half-initialized module into sys.modules → later import raises ImportError, not ModuleNotFoundError #112096).provides_hooks:(memory providers drop lifecycle-hook declarations they neverregister_hook), sohermes plugins validate <bundled>passes (Follow-up to #97765: unconsumedhooks:field remains in 8 bundled plugin manifests —plugins validatefails,doctorwarns #108371)._init_toolsetsexempts plugin toolset keys served byget_plugin_toolset_keys_nowait()from the startup "Unknown toolsets" warning, which fired every launch because validation ran before background discovery landed (Bug: Toolset validation runs before plugin load -- plugin-registered toolsets always trigger false-positive warning #71650, dups Startup warningWarning: Unknown toolsets: <plugin_toolset>— validate_toolset runs before plugin discovery, flags valid plugin toolsets #91757 Plugin-registered toolsets falsely warned as 'Unknown toolsets' — cli.py validation runs before plugin discovery #95529).Validation
running_hermes_version()on this editable install0.21.0(dist) — pluginrequires_hermes: ">=0.21.4"skipped0.21.4— plugin loadsversion_satisfies(">=99.0.0rc1", "0.21.4")True(clause dropped)Falsehermes chat -Q -q "say hi"with asys.exit(0)plugin enabledSystemExit(0) raised during import/register()uv pip install --show-settingscwd=$HOMEexclude_newer: Nonecwd=<checkout>→14dimpostor_dir/plugin.yamlname: disk-cleanupsrc=user path=…/impostor_dirsrc=bundled, WARNING names the dir and the renamefeature_missing("memory.hindsight")with 0.9.2 installed("hindsight-client==0.6.1",)→ reinstall/downgrade()scripts/run_tests.sh tests/hermes_cli/ tests/plugins/ tests/tools/test_lazy_deps.py tests/tools/test_plugin_skills.py tests/test_project_metadata.py tests/test_packaging_metadata.pyprovides_hooks:renameLive repro: before —
HOME=<fake> HERMES_HOME=<fake>/.hermes python -m hermes_cli.main chat -Q -q "say hi"with~/.hermes/plugins/b_exit/__init__.py=import sys; sys.exit(0)produced zero output and an empty plugin registry; after — normal provider round-trip,b_exitlisted with its error,c_afterloaded. Probe script:~/.hermes/cache/scratch/fixpack-0921/loader/probe_loader.py(A/B output in the PR thread on request).Root cause in one sentence: the loader trusted three things that drift from the running process — install-time dist metadata,
except Exceptionfor import failures, and uv's cwd-scoped project policy.Fixes #72052, fixes #104404, fixes #101962, fixes #86996, fixes #112096, fixes #108371, fixes #71650, fixes #91757, fixes #95529, fixes #86992, fixes #39424, fixes #98407, fixes #99317. Part of #14066 (discovery half).
Supersedes (with credit) #72094, #104418, #101977, #87014, #112097, #108386, #97765, #116425, #99557, #98416, #98527, #39754, #91761, #89345, #89351.
Deferred (outside this lane's files or needs a design call): #53704 (
main.pytop-level KeyboardInterrupt), #48822 (plugins/memory/__init__.pyflat scan), #108139 (no cheap per-pluginregister()deadline; #108144 fixes lock re-entry only), #86231/#59547/#107098 (config.py/toolset_validation.py), #89078 (secret-source registry warning at dotenv time), #110084 (env_loader→configimport order), #98438 (providers/__init__.pyep.load before kind gate), #95855 (third-party dep graph), #69148 (user backend copy vs bundled auto-load — policy).Infographic